마이크로태스크와 매크로태스크의 실행 순서

마이크로태스크와 매크로태스크의 실행 순서

Promise.then과 setTimeout을 섞은 코드의 출력 순서는 코드 위치만으로 결정되지 않는다. 현재 실행 중인 작업이 끝난 뒤 마이크로태스크 큐를 먼저 비우고 다음 태스크로 이동하는 규칙이 핵심이다.

한눈에 보기

  • 현재 call stack이 끝나면 마이크로태스크를 모두 처리한다.
  • Promise reaction과 queueMicrotask는 마이크로태스크에 들어간다.
  • timer와 이벤트 콜백은 다음 태스크에서 실행된다.

예시 코드 안내

본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.

목차

기본 실행 순서

console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve()
  .then(() => console.log("C"))
  .then(() => console.log("D"));

console.log("E");

출력은 A, E, C, D, B 순서다. 동기 코드가 먼저 끝나고 Promise 콜백인 마이크로태스크를 처리한 다음 타이머 태스크로 이동한다.

마이크로태스크가 계속 생기면

function repeat() {
  queueMicrotask(repeat);
}

repeat();

마이크로태스크가 자신을 계속 등록하면 브라우저는 렌더링으로, Node.js는 다음 이벤트 루프 단계로 넘어가기 어렵다.

짧다고 무조건 안전하지 않다

각각의 콜백이 짧아도 끝없이 이어지면 다른 작업이 실행될 기회를 잃는다.

UI와 서버에서의 영향

브라우저에서는 많은 마이크로태스크가 프레임 렌더링을 늦출 수 있다. 서버에서는 요청 완료 콜백과 I/O 처리를 지연시킬 수 있다. 긴 작업은 적절한 태스크 경계로 나눠 다른 작업에 실행 기회를 준다.

마이크로태스크 안에서 만든 마이크로태스크

마이크로태스크를 처리하는 동안 새로운 마이크로태스크가 등록되면 런타임은 보통 큐가 빌 때까지 계속 처리한다.

console.log("start");

queueMicrotask(() => {
  console.log("microtask:1");
  queueMicrotask(() => console.log("microtask:2"));
});

setTimeout(() => console.log("timer"), 0);

console.log("end");

출력 흐름은 다음과 같다.

start
end
microtask:1
microtask:2
timer

현재 스택이 끝난 뒤 첫 번째 마이크로태스크를 실행한다. 그 안에서 추가한 두 번째 마이크로태스크도 다음 timer 태스크로 넘어가기 전에 처리한다.

flowchart LR
    A[Script task] --> B[Microtask checkpoint]
    B --> C[microtask 1]
    C --> D[microtask 2 추가]
    D --> E[microtask 2 실행]
    E --> F[Rendering opportunity]
    F --> G[Timer task]

브라우저 렌더링과의 관계

브라우저는 일반적으로 하나의 task와 마이크로태스크 처리를 마친 뒤 렌더링할 기회를 얻는다. 따라서 마이크로태스크가 오래 이어지면 DOM을 이미 수정했어도 화면 반영이 늦어질 수 있다.

button.addEventListener("click", () => {
  status.textContent = "처리 중";

  let remaining = 50_000;
  function continueWork() {
    doSmallCalculation();
    remaining -= 1;
    if (remaining > 0) queueMicrotask(continueWork);
  }

  queueMicrotask(continueWork);
});

각 계산은 짧지만 마이크로태스크가 계속 이어진다. 브라우저가 “처리 중”이라는 텍스트를 그릴 기회가 늦어질 수 있다. 화면 반영이 필요한 긴 작업이라면 requestAnimationFrame, timer, Worker처럼 렌더링 기회를 주는 경계를 선택해야 한다.

status.textContent = "처리 중";

requestAnimationFrame(() => {
  runChunkedWork();
});

requestAnimationFrame 콜백 안에서 다시 긴 동기 작업을 하면 프레임을 막는 것은 동일하다. 작업을 프레임 예산 안의 작은 청크로 나누는 것이 핵심이다.

async/await도 Promise 흐름이다

await 뒤의 코드는 Promise reaction으로 이어진다.

async function run() {
  console.log("async:before");
  await null;
  console.log("async:after");
}

console.log("script:before");
run();
Promise.resolve().then(() => console.log("promise"));
console.log("script:after");

대략적인 출력은 다음과 같다.

script:before
async:before
script:after
async:after
promise

run()을 호출하면 첫 await 전까지는 동기적으로 실행된다. await 뒤의 나머지는 마이크로태스크로 예약되고, 그 다음 줄에서 등록한 then보다 먼저 큐에 들어간다.

await는 무조건 다음 프레임이 아니다

await Promise.resolve()로 나눈 코드는 마이크로태스크 경계일 뿐이다. 브라우저 렌더링이나 I/O에 실행 기회를 주고 싶다면 목적에 맞는 다른 스케줄링 API가 필요하다.

순서를 추론하는 순서

  1. 먼저 현재 call stack에서 동기적으로 실행되는 코드를 표시한다.
  2. 각 API가 마이크로태스크인지 다음 task인지 분류한다.
  3. 현재 task가 끝난 뒤 마이크로태스크 큐를 등록 순서대로 따라간다.
  4. 마이크로태스크 안에서 새 작업이 추가되는지 확인한다.
  5. 큐가 빈 뒤 렌더링과 다음 task로 넘어간다.

테스트에서는 출력 순서를 무작정 암기하기보다 이 다섯 단계를 종이에 적는 편이 훨씬 안정적이다.

결론

마이크로태스크는 현재 작업과 다음 태스크 사이의 우선 처리 큐다. 비동기 코드의 순서를 추론할 때 call stack, 마이크로태스크, 다음 태스크 순서로 그려 본다.

관련 노트